Skip to content

Keep the port protocol in generated snippets - #63

Merged
ahze merged 2 commits into
daemonless:mainfrom
jtrotsky:fix/readme-port-protocol
Nov 1, 2026
Merged

ahze merged 2 commits into
daemonless:mainfrom
jtrotsky:fix/readme-port-protocol

Conversation

@jtrotsky

@jtrotsky jtrotsky commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

  1. Port protocol dropped from snippets. A service that publishes the same port on UDP and TCP got two identical 53:53 lines in its compose and podman run snippets. UDP was lost.
  2. Short form "53/udp" parsed wrong. The protocol stayed on the published port, giving 53/udp:53/udp.
  3. Root images had a contradictory "User:" line. It said the image runs as root and as 1000:1000.

Before / after

Same compose input (53:53/udp, 53:53/tcp, user: root), rendered on main and on this branch.

Compose snippet:

     ports:
-      - "53:53"
+      - "53:53/udp"
       - "53:53"

podman run snippet:

-  -p 53:53 \
+  -p 53:53/udp \
   -p 53:53 \

Implementation details:

-**User:** `root` (UID/GID via PUID/PGID, defaults to 1000:1000)
+**User:** `root` — the service process stays root by design, not dropped to `bsd`

Parsed ports: ["53/udp"]:

-{'published': '53/udp', 'target': '53', 'protocol': 'udp'}
+{'published': '53',     'target': '53', 'protocol': 'udp'}

How

  • proto_suffix() macro in README.j2 adds /<proto> only when it isn't tcp. TCP is the default for compose and podman run -p, so TCP-only images render byte-identical. The comparison ignores case, so protocol: TCP doesn't add a suffix.
  • config.py strips /<proto> from published in the single-field short form.
  • config.py lowercases the protocol at parse time, in both the compose and legacy config.yaml paths. 53:53/UDP used to reach the AppJail snippet as proto:UDP. An empty legacy protocol: now defaults to tcp, where it used to render as proto:None.
  • The User: line is conditional on user == "root" in both README.j2 and README.mkdocs.j2. The mkdocs template overrides that block, so fixing only one would miss the docs site.

Tests

New tests/test_port_protocol.py, 7 tests:

Test main this PR
dual protocol: README, docs site, podman run FAIL pass
short form 53/udp: parses to published: 53, renders 53:53/udp FAIL pass
root image: no 1000:1000 claim (README + docs site) FAIL pass
TCP-only image: no /tcp added pass pass
8080:8080/TCP: no suffix, AppJail gets proto:tcp FAIL pass
53:53/UDP: lowercased in compose, AppJail and podman run FAIL pass
non-root image: PUID/PGID line unchanged pass pass

The two that pass on main are regression guards. They make sure the fix doesn't change output it shouldn't.

Full suite: 579 tests, no failures. test_screenshot fails to import on both main and this branch, because it needs pytest and the test environment has none. That is unrelated to this change. ruff was not run.

jtrotsky and others added 2 commits November 1, 2026 15:06
A service that publishes the same port on both protocols rendered an invalid
compose file. The generator dropped the `/proto` suffix from the Podman Compose
and Podman CLI snippets, so a compose.yaml declaring

    ports:
      - 53:53/udp
      - 53:53/tcp

produced

    ports:
      - "53:53"
      - "53:53"

Podman rejects the duplicate mapping. Where a runtime tolerates it, the
copy-pasted result is TCP-only: a DNS resolver that answers `dig +tcp` and
nothing a normal stub resolver sends, which looks like a broken image rather
than a broken snippet. The AppJail snippets always carried `proto:udp` /
`proto:tcp`, and so did the parameters table, so the protocol was known and only
the docker-style mappings lost it.

Fixed with a `proto_suffix()` macro that emits the suffix only when the protocol
is not the `tcp` default. A bare `4000:4000` already means tcp for both compose
and `podman run -p`, so emitting it everywhere would churn every README in the
catalogue for no benefit; this way single-protocol images render byte-identical
to before. The comparison is case-insensitive and the value is emitted
lowercased, because compose accepts `protocol: TCP` and `53:53/UDP` and those
were stored verbatim — a raw comparison would have appended `/TCP` to images
that should have been untouched.

Also fixes the single-field short form in config.py. `ports: ["53/udp"]` is one
string for both ports, so the protocol stayed stuck on the published port and
the snippet rendered `53/udp:53/udp`. Stripped at parse time rather than in the
template, since `published` is documented as a port number and every consumer
of cfg.ports would otherwise need the same guard.

Separately, the implementation-details line contradicted itself for images that
run as root:

    **User:** `root` (UID/GID via PUID/PGID, defaults to 1000:1000)

That tells the reader both that the container runs as root and that it runs as
1000:1000. A root image stays root deliberately — binding a port below 1024
needs it, and FreeBSD has no capability mechanism to delegate one. The line is
now conditional, and the wording states only what is certain, that the service
process stays root. It deliberately does not claim PUID/PGID are unused: a root
image can still honour them for the base init's chown, and some READMEs print a
PUID row in the env table on the same page.

Applied to both README.j2 and README.mkdocs.j2. The mkdocs template overrides
the implementation_details block, so a fix in README.j2 alone never reaches the
docs site, which is the page most readers see. Both now carry a comment pointing
at the other.

Known limitation, left alone deliberately: substitution of the configurable port
is keyed on `loop.first`, so for a dual-protocol service only the first mapping
is parameterised and the docs-site port control splits the service across two
host ports. That predates this change and fixing it means keying on target port
across README.j2 and appjail-director.yml.j2, which affects every multi-port
image. Filed separately rather than widened into this one.

Not covered by tests. The existing suite passes (46 docs tests), but neither new
behaviour has a test; worth adding if the project wants the gate to hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jtrotsky
jtrotsky requested a review from ahze November 1, 2026 06:40
@ahze
ahze merged commit b9a9544 into daemonless:main Nov 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants